SPB Git
7.7 KB · 40 lines tsx
1import { notFound } from "next/navigation";2import { sql } from "@/db";3import { requireUserPage } from "@/lib/auth/guard";4import { csrfToken } from "@/lib/auth/session";5import { ingestAction } from "@/lib/admin/actions";6import { Csrf, Field, Notice, PageTitle, Select } from "@/components/admin-ui";7export default async function Candidate({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<Record<string, string | undefined>> }) {8  const { id } = await params; const sp = await searchParams; const u = await requireUserPage(); const csrf = csrfToken(u);9  const [c] = await sql`SELECT c.*, f.pages, f.integrity_ok, f.pdf_meta, f.sha256 AS fsha FROM ingest_candidates c LEFT JOIN files f ON f.id = c.file_id WHERE c.id = ${id}`; if (!c) notFound();10  const pr = (c.proposal ?? {}) as Record<string, any>; const [authors, series, papers] = await Promise.all([sql`SELECT id, display_name FROM authors ORDER BY sort`, sql`SELECT code, name_en FROM series ORDER BY sort`, sql`SELECT id, identifier, title FROM papers ORDER BY number DESC`]);11  const guessAuthors = new Set((authors as unknown as { id: number; display_name: string }[]).filter((a) => (pr.authors ?? []).some((n: string) => n.toLowerCase().includes(a.display_name.split(" ").pop()!.toLowerCase()))).map((a) => a.id));12  const MONTH: Record<string, number> = { january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, july: 7, august: 8, september: 9, october: 10, november: 11, december: 12, janvier: 1, février: 2, mars: 3, avril: 4, mai: 5, juin: 6, juillet: 7, août: 8, septembre: 9, octobre: 10, novembre: 11, décembre: 12 };13  const dm = pr.dateText ? /^(\S+)\s+(\d{4})$/.exec(pr.dateText) : null; const dateGuess = dm && MONTH[dm[1].toLowerCase()] ? `${dm[2]}-${String(MONTH[dm[1].toLowerCase()]).padStart(2, "0")}` : "";14  return (<div><PageTitle title={`Candidate · ${String(c.source_path).split("/").pop()}`} /><Notice sp={sp} />15    <div className="grid lg:grid-cols-[1fr_22rem] gap-6">16      <div className="space-y-6">17        <section className="card p-4 text-sm"><h2 className="text-lg mb-2">Extraction report <span className={`chip ${c.status === "ready" ? "chip-navy" : ""}`}>{c.status}</span></h2>18          <dl className="grid sm:grid-cols-2 gap-2"><div><dt className="label">SHA-256</dt><dd className="font-mono text-xs break-all">{c.sha256}</dd></div><div><dt className="label">Integrity</dt><dd>{c.integrity_ok === null ? "pending" : c.integrity_ok ? "OK" : <span className="text-bad">FAILED</span>} · {c.pages ?? "?"} pages</dd></div><div><dt className="label">PDF metadata</dt><dd className="text-fg-2">{pr.pdfMeta ? `${pr.pdfMeta.title ?? "—"} / ${pr.pdfMeta.author ?? "—"} / ${pr.pdfMeta.creationDate ?? "—"}` : "—"}</dd></div><div><dt className="label">Confidence</dt><dd className="font-mono text-xs">{pr.confidence ? Object.entries(pr.confidence).map(([k, v]) => `${k}:${Number(v).toFixed(1)}`).join(" ") : "—"}</dd></div></dl>19          {(c.flags as { code: string; message: string }[]).length > 0 && <ul className="mt-3 space-y-1">{(c.flags as { code: string; message: string }[]).map((f) => <li key={f.code} className="text-warn">⚠ {f.message}</li>)}</ul>}20          {(c.duplicates as { identifier: string; title: string; reasons: string[]; score: number; paperId: string }[]).length > 0 && <div className="mt-3"><p className="label">Possible duplicates</p><ul className="space-y-1">{(c.duplicates as { identifier: string; title: string; reasons: string[]; score: number }[]).map((d) => <li key={d.identifier}><span className="font-mono text-navy">{d.identifier}</span> {d.title} — {d.reasons.join(", ")} ({(d.score * 100).toFixed(0)}%)</li>)}</ul></div>}21          {pr.abstract && <details className="mt-3"><summary className="label">Proposed abstract</summary><p className="text-fg-2 mt-1 leading-relaxed">{pr.abstract}</p></details>}22          <p className="meta mt-3">The proposal below is a suggestion extracted from the title page. Verify every field against the manuscript. The release date must come from the document, never from the file timestamp. Author consent to publication is not inferred.</p>23        </section>24        {c.status === "ready" && c.integrity_ok && (<>25          <form action={ingestAction} className="card p-4 space-y-4"><h2 className="text-lg">Approve as a new working paper (draft)</h2><Csrf token={csrf} /><input type="hidden" name="candidate_id" value={c.id} /><input type="hidden" name="op" value="approve" />26            <div className="grid sm:grid-cols-4 gap-3"><Select label="Series" name="series_code" value={pr.wp?.seriesCode ?? "WP"} options={series.map((s) => ({ v: s.code, label: `${s.code} — ${s.name_en}` }))} /><Field label="Number" name="number" type="number" value={pr.wp?.number} required /><Field label="Version label" name="version_label" value={pr.version ? `v${pr.version}` : "v1"} /><Field label="Release date (YYYY-MM[-DD])" name="released_on" value={dateGuess} hint={pr.dateText ? `detected: ${pr.dateText}` : "not detected"} /></div>27            <Field label="Title" name="title" value={pr.title} required /><Field label="Abstract" name="abstract" value={pr.abstract} rows={6} /><div className="grid sm:grid-cols-3 gap-3"><Field label="Keywords (one per line)" name="keywords" value={(pr.keywords ?? []).join("\n")} rows={4} /><Field label="JEL" name="jel" value={(pr.jel ?? []).join(", ")} /><Field label="Year" name="year" type="number" value={dm ? Number(dm[2]) : ""} /></div>28            <div><p className="label mb-1">Authors (detected: {(pr.authors ?? []).join("; ") || "none"})</p><div className="grid sm:grid-cols-3 gap-1">{authors.map((a) => <label key={a.id} className="text-sm flex gap-2"><input type="checkbox" name="author_ids" value={a.id} defaultChecked={guessAuthors.has(a.id)} />{a.display_name}</label>)}</div><p className="text-xs text-fg-3 mt-1">Unknown authors must first be created in Taxonomy.</p></div>29            <button className="btn btn-primary">Create draft paper</button></form>30          <form action={ingestAction} className="card p-4 space-y-3"><h2 className="text-lg">Attach as a new version of an existing paper</h2><Csrf token={csrf} /><input type="hidden" name="candidate_id" value={c.id} /><input type="hidden" name="op" value="attach" />31            <Select label="Target paper" name="target_paper_id" value={(c.duplicates as { paperId?: string }[])[0]?.paperId ?? ""} options={papers.map((p) => ({ v: p.id, label: `${p.identifier} — ${p.title.slice(0, 80)}` }))} />32            <div className="grid sm:grid-cols-3 gap-3"><Field label="Version label" name="version_label" value={pr.version ? `v${pr.version}` : ""} /><Field label="Release date" name="released_on" value={dateGuess} /><Field label="Description" name="description" value="Revised manuscript" /></div>33            <label className="flex gap-2 text-sm"><input type="checkbox" name="make_current" defaultChecked /> Make it the current version (re-indexes full text)</label><button className="btn">Attach version</button></form>34        </>)}35        <div className="flex gap-2"><form action={ingestAction} className="flex gap-2"><Csrf token={csrf} /><input type="hidden" name="candidate_id" value={c.id} /><input type="hidden" name="op" value="reject" /><input name="note" placeholder="reason" className="input !w-56" /><button className="btn btn-sm text-bad">Reject</button></form><form action={ingestAction}><Csrf token={csrf} /><input type="hidden" name="candidate_id" value={c.id} /><input type="hidden" name="op" value="reprocess" /><button className="btn btn-sm">Re-run extraction</button></form></div>36      </div>37      <aside className="card p-3 self-start">{c.fsha ? <img src={`/api/admin/preview/${c.fsha}`} alt="First page preview" className="w-full border border-line" /> : null}<p className="meta mt-2 break-all">{c.source_path}</p></aside>38    </div></div>);39}40
Files · main
Menu

Theme